Skip to content

feat(compiler): add @strictExtends to require union variants to nominally extend the base type - #11780

Draft
JoshLove-msft wants to merge 11 commits into
microsoft:mainfrom
JoshLove-msft:josh/union-strict-extends
Draft

feat(compiler): add @strictExtends to require union variants to nominally extend the base type#11780
JoshLove-msft wants to merge 11 commits into
microsoft:mainfrom
JoshLove-msft:josh/union-strict-extends

Conversation

@JoshLove-msft

@JoshLove-msft JoshLove-msft commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Important

Draft / design proposal. This is stacked on #11771 and #11779, so the diff below contains both. Only the @strictExtends commits are new here. Please review commit by commit.

This addresses #3900 (design:needed, no accepted design yet), so the shape below is a proposal, not a finished decision. Opening it as a draft to have something concrete to discuss.

Fixes #3900

Why

#2737 / #11771 added union Foo extends Base { ... }, and by design that clause is a structural constraint: any variant whose shape is assignable to Base satisfies it.

Emitters targeting languages without native unions (C#, Java, Go, ...) represent such a union as a polymorphic base type, and that representation only works if every variant actually derives from the base. A variant that merely happens to have the same shape cannot be emitted as a subclass.

@strictExtends is an opt in decorator that turns that requirement into a compile time error. It changes nothing for existing code and nothing about the default behaviour of #11771.

model Pet {
  name: string;
}
model Cat extends Pet {
  meow: boolean;
}
model Rock {
  name: string;
}

@strictExtends
union Pets extends Pet {
  cat: Cat, // ok: `Cat` extends `Pet`
  rock: Rock, // error: `Rock` has the same shape as `Pet` but doesn't extend it
}

Semantics

Variant Satisfies @strictExtends?
The base type itself yes
A model extending the base model, transitively yes
A scalar extending the base scalar, transitively yes
A string / numeric / boolean literal only when the base type is that exact standard scalar
A member of the base enum, or the base enum itself yes
A union when everything reachable through its variants does
never, an empty union, a union cycle yes, vacuously: they describe no value
Anything else no

The base type must be a model, a scalar or an enum. Those are the only types that can be explicitly extended, so @strictExtends on a union whose base is another union, a tuple or unknown is an error rather than a silent no-op.

A union carries no value of its own, so the check is really a reachability question: is anything offending reachable from the variant through union edges? That framing is what makes cycles, empty unions and never fall out for free, and it keeps composition closed - a union that satisfies @strictExtends on its own is always usable as a variant of another one.

Implementation notes

  • Validation runs from onGraphFinish. Anything earlier is observably wrong: a union that is part of a cycle is still being built when the decorated union finishes, which made the outcome depend on the declaration order of the other union''s variants. It also means a decorator applied later on the same union cannot invalidate the guarantee. Both cases have tests.
  • That surfaced a pre-existing checker bug, fixed here in its own commit: postCheckValidators is drained once, at the end of checkProgram, so an onGraphFinish validator registered for a type created after that (a clone a mutator produces during $onValidate, for example) was registered and never run. The checker now runs it as soon as the type is finished. @typespec/http is the other onGraphFinish consumer and had the same latent hole. Detecting this in the decorator via program.currentStage is not enough: the stage stays "checking" when the checker reported errors, which is exactly the state a language server program is left in.
  • The walk is a plain reachability traversal with a visited set, so it is linear in the size of the union graph including cycles. There are regression tests at depth 30 for both an acyclic and a cyclic diamond; an earlier memoization scheme that disabled reuse around cycles took ~8s at depth 24.
  • A variant that is not even assignable to the base type gets both unassignable and strict-extends-variant. Suppressing the second one required assuming the checker had already reported the first, which is not true once a decorator rewrites the variants.
  • Applying the decorator more than once, directly or through @@strictExtends, validates the union once.

Open design questions

  1. Error or warning? Currently an error. A warning would let a spec adopt the decorator incrementally.
  2. Should this be a decorator at all, or syntax (for example union Foo extends strict Base)? A decorator keeps feat(compiler): add extends base type clause for unions #11771 untouched and is easy to remove if the design lands differently.
  3. Should the base type be allowed to be an enum? It is allowed here for symmetry, but enum member assignability is already nominal, so it adds no constraint beyond rejecting non members.
  4. Post-check onGraphFinish is weaker than at graph finish. For a type created after checking it can only see the graph as it exists when that type is finished, so it cannot observe relationships attached later. That is disclosed on DecoratorValidatorCallbacks.onGraphFinish; giving it true graph-finish semantics would need an explicit "dynamic graph completed" operation, which felt out of scope here.
  5. Should emitters get a public helper (isStrictExtends(program, union)) instead of each one re-deriving it? The state is tracked internally already.
  6. Double diagnostic: is unassignable + strict-extends-variant on the same variant acceptable, or should the strict one be suppressed at the cost of the soundness hole described above?

Validation

  • tsc -p tsconfig.build.json --noEmit clean
  • 4237 compiler tests pass, including 36 new @strictExtends tests
  • @typespec/openapi3 2578 tests pass, @typespec/http 21/21 files pass
  • pnpm lint clean, prettier clean
  • Every non obvious test was mutation verified: reverting to onTargetFinish, restoring the assignability suppression, removing the duplicate application guard, removing memoization and disabling reuse across cycles each make exactly the intended tests fail.

Review history

Three adversarial review passes were run against this change. The first found 6 issues (silent bypass for non model base types, exponential walk, decorator ordering, composition not closed, missing export, diagnostic quality); the second found 3 more (declaration order dependence, the assignability suppression bypass, exponential behaviour on cyclic graphs) plus duplicate diagnostics on repeated application. The third found that onGraphFinish validators registered after checking never ran, which is the checker fix above; the fourth found nothing further. All are fixed and covered by tests.

--generated by Copilot

JoshLove-msft and others added 7 commits August 26, 2026 21:45
A named union can now declare a base type with `extends`. Every variant
must be assignable to that base type, and the resolved type is exposed on
the type graph as `Union.baseType` so emitters can represent the union
with a polymorphic base type in languages without native unions.

`extends` on a union is purely a constraint: it doesn't create any
inheritance relationship, the base type doesn't become a variant, it
doesn't make the union extensible and it has no interaction with
`@discriminator`.

Fixes microsoft#2737

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
…end the base type

The `extends` clause of a union is a structural constraint: any variant with
a compatible shape satisfies it. Emitters targeting languages without native
unions represent such a union with a polymorphic base type, which requires each
variant to actually derive from the base type.

`@strictExtends` turns that into a compile time error. It only adds a
constraint when the base type is a model, since assignability between scalars
is already nominal. A variant that is itself a union satisfies the constraint
when all of its own variants do, so unions can still be composed.

Implements the opt-in decorator proposed in microsoft#2737 and tracked by microsoft#3900.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
…rsive types

`isTypeAssignableToInternal` created a brand new relation cache for every
nested call instead of forwarding the one it was given, so the "in progress"
entry seeded by `areModelsRelated` only survived a single level and mutually
recursive models recursed forever. Unions were never seeded at all, so any
union reaching itself did the same.

Forwarding the cache alone is not enough: the cache stored only the `Related`
result and dropped the errors, and `areModelsRelated` turns a result with no
errors back into `Related.true`. The cache now stores the errors alongside the
result.

A purely cyclic union describes an empty set of values, so it is vacuously
assignable to anything, and the dual seed is used on the target side where
being assignable to a union only requires one variant to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
Address the review findings on the first `@strictExtends` implementation.

- Validate from `onTargetFinish` instead of during decorator application so a
  decorator applied later cannot invalidate the guarantee.
- Report an error when the base type is not a model, a scalar or an enum
  instead of silently accepting every variant.
- Handle scalars, string/number/boolean literals and enum members instead of
  only models, so a literal that is structurally assignable to a custom scalar
  is rejected.
- Treat `never`, an empty union and a union cycle as satisfying the constraint
  so composition is closed: a union that `@strictExtends` accepts on its own is
  always usable as a variant of another one.
- Memoize the walk, which was exponential on a union graph reachable through
  many paths. Results that relied on short circuiting a cycle are not memoized
  because they are only valid for that walk.
- Name the offending leaf and point the diagnostic at the variant type
  expression instead of the whole variant.
- Stay silent when the base type failed to resolve, and when the variant is not
  even assignable to the base type, since the checker already reported both.
- Export `$strictExtends` from the package entry point like the other built in
  decorators.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
@microsoft-github-policy-service microsoft-github-policy-service Bot added compiler:core Issues for @typespec/compiler meta:website TypeSpec.io updates labels Aug 27, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/compiler@11780

commit: 8e8cddf

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @typespec/compiler
Show changes

@typespec/compiler - fix ✏️

Run the onGraphFinish validator of a decorator applied to a type created after the type graph was checked, a clone a mutator produced during $onValidate for example. Those validators used to be registered but never run.

@typespec/compiler - fix ✏️

Fix a stack overflow when checking assignability of mutually recursive types,> ,> Checking whether a type was assignable to another one could recurse forever and crash the compiler with RangeError: Maximum call stack size exceeded. Two cases were affected:,> ,> - mutually recursive models, such as model A { b: B } / model B { a: A },> - any union reaching itself, such as union Foo { self: Foo },> ,> The relation cache is now shared for the whole check instead of being recreated at every level, and unions seed it before walking their variants, so a cycle coming back to the same pair of types resolves instead of recursing.

@typespec/compiler - feature ✏️

Add support for an extends clause on union statements to constrain every variant to a common base type.,> ,> tsp,> model PetBase {,> name: string;,> },> model Cat extends PetBase {,> toy: string;,> },> model Dog extends PetBase {,> food: string;,> },> ,> union Pet extends PetBase {,> cat: Cat,,> dog: Dog,,> },> ,> ,> The base type is exposed on the type graph as Union.baseType, giving emitters an easy way to know that all the variants of a union share a common base type. A diagnostic is reported on any variant that isn't assignable to the base type.,> ,> extends on a union is purely a constraint: it doesn't imply any subtyping relationship, it doesn't make the union extensible and it has no interaction with @discriminator.

@typespec/compiler - feature ✏️

Add @strictExtends to require every variant of a union to explicitly extend the base type declared by the union extends clause.,> ,> By default the extends clause of a union is a structural constraint: any variant with a compatible shape satisfies it. Emitters targeting languages without native unions represent such a union with a polymorphic base type, which requires each variant to actually derive from the base type.,> ,> tsp,> model Pet {,> name: string;,> },> model Cat extends Pet {,> meow: boolean;,> },> model Rock {,> name: string;,> },> ,> @strictExtends,> union Pets extends Pet {,> cat: Cat, // ok: `Cat` extends `Pet`,> rock: Rock, // error: `Rock` has the same shape as `Pet` but doesn't extend it,> },> ,> ,> @strictExtends can only be used when the base type is a model, a scalar or an enum, since those are the only types that can be explicitly extended. A variant that is itself a union satisfies the constraint when all of its own variants do, so unions can still be composed.

Follow up on a second adversarial review pass.

- Validate from `onGraphFinish` instead of `onTargetFinish`. A union that is
  part of a cycle is still being built when the decorated union finishes, so
  validating earlier made the result depend on declaration order: moving the
  offending variant before the back edge changed an accepted program into a
  rejected one.
- Stop assuming a variant that isn't assignable to the base type was already
  reported by the checker. The checker validates the variants a union was
  declared with, so a decorator that replaces one afterwards would bypass the
  validation entirely. A variant that satisfies neither constraint now reports
  both.
- Replace the memoized walk with a plain reachability walk. A union carries no
  value of its own, so the question is only whether anything offending is
  reachable through its variants, which makes cycles fall out naturally. The
  previous cycle guard disabled memoization for every ancestor of a cycle and
  was exponential on a cyclic graph reachable through many paths: 8s at depth
  24, instant now at depth 30.
- Validate a union once even when the decorator is applied more than once,
  directly or through `@@strictExtends`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
@azure-sdk-automation

azure-sdk-automation Bot commented Aug 27, 2026

Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

JoshLove-msft and others added 3 commits August 27, 2026 15:14
Graph finish validators are only drained once, at the end of the checking
stage, so a `@strictExtends` union created after that - for example a clone
a mutator produces during `$onValidate` - registered a validator that was
never run, and was silently never validated.

Keep deferring to graph finish while the program is being checked, which is
what makes the result independent of declaration order for cyclic unions,
and validate at target finish otherwise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
…hecking

`postCheckValidators` is drained once, at the end of `checkProgram`, so a
decorator applied to a type created after that - a clone a mutator produced
during `$onValidate` for example - registered an `onGraphFinish` validator
that was never run.

The checker now runs those validators as soon as the type is finished, since
there is no graph finish left to wait for. `@strictExtends` relies on this:
it validates at graph finish so the result doesn't depend on the declaration
order of a cyclic union, and a post-check clone would otherwise silently
never be validated.

Using `program.currentStage` to detect this in the decorator instead is not
enough: the stage stays `"checking"` when the checker reported errors, which
is exactly the state a language server program is left in.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
…heck types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compiler:core Issues for @typespec/compiler meta:website TypeSpec.io updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nominal types in TypeSpec

1 participant